In questo laboratorio vedremo un esempio di Web Scraping mediante la libreria BeautifulSoup4. In particolare, vedremo che il web scraping consiste in due fasi (che vengono in genere alternate):
In questo laboratorio, mostreremo degli esempi di esplorazione DOM con Google Chrome, ma altri browsers (per esempio Mozilla Firefox) possono essere utilizzati in maniera simile.
Per prima cosa installiamo la libreria mediante il seguente comando: pip install bs4.
Consideriamo l'ecommerce Amazon (https://www.amazon.it/). Inseriamo nella barra di ricerca: "occhiali da sole". Verrà visualizzata la seguente pagina web:
In particolare, la pagina mostra la lista degli 'occhiali da sole' in vendita sul sito. Notiamo che per ogni prodotto sono riportate diverse informazioni, quali ad esempio:
Notiamo inoltre, che la pagina mostra solo una parte dell'elenco (articoli 1-48 di più di 70000) e che in fondo alla pagina sono disponibili dei link per passare alle pagine successive.
In questo laboratorio vedremo come costruire uno script che permette di navigare tra le pagine ed estrarre le informazioni elencate sopra in maniera automatica. Dato che il sito non espone una API, questo processo resta 'semi-manuale'. Sfrutteremo infatti il contenuto HTML. Va notato che, benché HTML è strutturato, esso non è stato pensato per permettere a delle macchine di comunicare tra loro (non è una API!), ma per permettere al browser di visualizzare delle pagine per l'utente finale. Pertanto le strutture HTML delle pagine sono in genere ambigue e poco standard. Per questo motivo sarà necessario esplorare manualmente il DOM caso per caso.
Iniziamo facendo click col tasto destro sulla parte della pagina che contiene l'item e facendo click su 'ispeziona'. Si aprirà un inspector sulla destra (o in basso). Uno degli elementi HTML verrà automaticamente evidenziato. Scorriamo la lista degli elementi HTML nell'inspector e facciamo click su di essi per vedere a quali elementi grafici essi corrispondono. Navigando nel DOM, dovremmo trovare un div contraddistinto da diverse classi, tra cui puis-card-container s-card-container s-overflow-hidden aok-relative puis-expand-height puis-include-content-margin puis puis-vwvhvgkypx2z322f29wcc4lx0s s-latency-cf-section puis-card-border.
Se scorriamo la struttura del documento nell'inspector, notiamo che la pagina contiene una lista di elementi div di classe puis-card-container s-card-container s-overflow-hidden aok-relative puis-expand-height puis-include-content-margin puis puis-vwvhvgkypx2z322f29wcc4lx0s s-latency-cf-section puis-card-border. Ciascuno di questi div contiene a sua volta gli elementi relativi a ciascun prodotto. Per verificare la nostra intuizione, iniziamo ad analizzare la pagina html mediante BeautifulSoup. Per poterlo fare, dovremo prima scaricare il contenuto grezzo della pagina HTML mediante urllib:
from urllib.request import urlopen as uRequest
uClient=uRequest("https://www.amazon.it/s?k=occhiali+da+sole&__mk_it_IT=%C3%85M%C3%85%C5%BD%C3%95%C3%91&crid=X83F9HUOVIJY&sprefix=occhiali+da+sole%2Caps%2C118&ref=nb_sb_noss_1")
page_html = uClient.read()
print(type(page_html))
La variabile page_html contiene il contenuto 'grezzo' della pagina. La pagina può essere molto lunga, quindi è in genere sconsigliato provare a stamparla nella sua interezza con una print (ciò può bloccare il programma). Visualizziamo i primi $1000$ caratteri:
print(page_html[:1000])
Teoricamente, potremmo fare il parsing della pagina in maniera manuale. In pratica, dato che le pagine HTML hanno una struttura generale simile (sono tutte composte da tag organizzati in maniera gerarchica), esistono diverse librerie per semplificare la loro manipolazione. Importiamo BeautifulSoup e processiamo la pagina:
from bs4 import BeautifulSoup as soup
page_soup = soup(page_html)
print(type(page_soup))
La variabile page_soup è adesso un oggetto di tipo BeautifulSoup. Tale oggetto ha a disposizione una serie di metodi che permettono di manipolare facilmente il documento, come vedremo a breve. Proviamo ad esempio a cercare tutti i div di classe puis-card-container s-card-container s-overflow-hidden aok-relative puis-expand-height puis-include-content-margin puis puis-vwvhvgkypx2z322f29wcc4lx0s s-latency-cf-section puis-card-border per vedere se la nostra intuizione è corretta:
containers=page_soup.findAll('div',{'class':'puis-card-container s-card-container s-overflow-hidden aok-relative puis-expand-height puis-include-content-margin puis puis-vwvhvgkypx2z322f29wcc4lx0s s-latency-cf-section puis-card-border'})
print(type(containers))
La chiamata a findAll ha restituito un oggetto di tipo ResultSet. Vediamo quanti elementi sono contenuti nel set:
print(len(containers))
Dato che la pagina contiene esattamente $48$ prodotti, ciò conferma la nostra intuizione che le informazioni di ciascun prodotto sono contenute dentro i div di classe puis-card-container s-card-container s-overflow-hidden aok-relative puis-expand-height puis-include-content-margin puis puis-vwvhvgkypx2z322f29wcc4lx0s s-latency-cf-section puis-card-border. Per avere una ulteriore verifica, proviamo a visualizzare il contenuto del primo container:
container=containers[0]
print(container)
type(container)
Se guardiamo attentamente, vedremo del testo compatibile con gli elementi disponibili per ogni prodotto. Ad esempio, possiamo scorgere un LINVO e un €18,99. Possiamo procedere analizzando ricorsivamente il contenuto di ciascun container per estrarre le informazioni che ci servono.
Cerchiamo di estrarre adesso il manufacturer (LINVO, nel caso del primo container). Se ispezioniamo il DOM con il browser (click con il tasto destro sul manufacturer e click su inspect), notiamo che esso è contenuto in uno span di classe a-size-base-plus a-color-base.
Proviamo ad analizzare il primo container per vedere se contiene uno span di quella classe:
container.findAll('span',{'class':'a-size-base-plus a-color-base'})
Abbiamo trovato il manufacturer! Il comando findAll, ci restituisce però una lista di oggetti (uno solo in questo caso). Possiamo accedere al testo contenuto nell'oggetto come segue:
manufacturer = container.findAll('span',{'class':'a-size-base-plus a-color-base'})[0].text
print(manufacturer)
Allo stesso modo, ispezionando il DOM, notiamo che il nome del modello è contenuto in uno span di classe a-size-base-plus a-color-base a-text-normal:
Estraiamo il modello dal container come segue:
model = container.findAll('span',{'class':'a-size-base-plus a-color-base a-text-normal'})[0].text
print(model)
Similmente notiamo che le immagini dei prodotti si trovano in un div di classe a-section aok-relative s-image-tall-aspect:
container.findAll('div',{'class':'a-section aok-relative s-image-tall-aspect'})
Notiamo che l'immagine si trova dentro il tag img. Possiamo accedere come segue:
container.findAll('div',{'class':'a-section aok-relative s-image-tall-aspect'})[0].img
La URL della immagine, si trova nell'attributo src, al quale possiamo accedere come segue:
img_url = container.findAll('div',{'class':'a-section aok-relative s-image-tall-aspect'})[0].img['src']
print(img_url)
Possiamo verificare che l'immagine sia corretta inserendo la URL nel browser, o caricandola mediante IPython:
import IPython
IPython.display.Image(img_url)
In maniera simile (analizzando il DOM manualmente e controllando) possiamo estrarre il prezzo:
price = container.findAll('span',{'class':'a-price-whole'})[0].text
print(price)
Convertiamo la stringa in un numero sostituendo la virgola con un punto:
price = float(price.replace(',','.'))
print(price)
In maniera del tutto analoga otteniamo il numero di recensioni:
review_count = container.findAll('span',{'class':'a-size-base s-underline-text'})[0].text
review_count = int(review_count.replace('.', ''))
print(review_count)
Per estrarre il rating, ispezionando il DOM, notiamo che esso è contenuto in uno span di classe a-icon-alt:
Il rating è espresso in quantità di stelline nel range 0 - 5: 4,3 su 5 stelle. Per ottenere il rating, dobbiamo dunque isolare il primo numero rispetto al resto. Iniziamo cercando i div di classe a-icon-alt:
container.findAll('span',{'class':'a-icon-alt'})
rating = container.findAll('span',{'class':'a-icon-alt'})[0].text
print(rating)
Siamo interessati solo al primo numero del rating, quindi rimuoviamo tutto il resto:
rating = float(rating.replace(' su 5 stelle', '').replace(',', '.'))
print(rating)
Possiamo automatizzare l'estrazione di queste informazioni in tutta la pagina mediante un ciclo for:
def scrape_page(page_url, _records = None):
if _records is None:
_records = []
page_html=uRequest(page_url).read()
page_soup=soup(page_html)
containers=page_soup.findAll('div',{'class':'puis-card-container s-card-container s-overflow-hidden aok-relative puis-expand-height puis-include-content-margin puis puis-vwvhvgkypx2z322f29wcc4lx0s s-latency-cf-section puis-card-border'})
for container in containers:
manufacturer = container.findAll('span',{'class':'a-size-base-plus a-color-base'})[0].text
model = container.findAll('span',{'class':'a-size-base-plus a-color-base a-text-normal'})[0].text
img_url = container.findAll('div',{'class':'a-section aok-relative s-image-tall-aspect'})[0].img['src']
try:
price = container.findAll('span',{'class':'a-price-whole'})[0].text
price = float(price.replace(',','.'))
except:
price = 0
try:
review_count = container.findAll('span',{'class':'a-size-base s-underline-text'})[0].text
review_count = int(review_count.replace('.', ''))
except:
review_count = 0
try:
rating = container.findAll('span',{'class':'a-icon-alt'})[0].text
rating = float(rating.replace(' su 5 stelle', '').replace(',', '.'))
except:
rating=0
_records.append([manufacturer, model, img_url, price, review_count, rating])
return _records
Adesso facciamo scraping della pagina e inseriamo il risultato in un DataFrame:
import pandas as pd
page_url = 'https://www.amazon.it/s?k=occhiali+da+sole&__mk_it_IT=%C3%85M%C3%85%C5%BD%C3%95%C3%91&crid=X83F9HUOVIJY&sprefix=occhiali+da+sole%2Caps%2C118&ref=nb_sb_noss_1'
records = scrape_page(page_url)
data = pd.DataFrame(records, columns=['manufacturer','model','img_url','price','review_count','rating'])
Visualizziamo le prime righe del DataFrame:
print(data.info())
data.head()
Il DataFrame contiene $48$ righe relative ai $48$ oggetti presenti nella pagina.
Abbiamo conservato le URL alle immagini nel DataFrame. Tuttavia, vista la natura dinamica dei siti web, queste potrebbero cambiare URL ed essere in futuro irraggiungibili. E' quindi in genere una buona idea scaricare anche questo tipo di dato e inserirlo in una cartella apposita. Iniziamo creando una cartella amazon_img:
import os
dest_dir = 'amazon_img'
os.makedirs(dest_dir, exist_ok=True)
Adesso dobbiamo scaricare ogni immagine e conservarla con un nome di file univoco. Per evitare che immagini diverse abbiano lo stesso nome, derivereremo il nome del file dall'id del DataFrame. Vediamo un esempio di download e salvataggio dell'immagine nel caso in una riga del dataframe:
row = data.iloc[0]
row
Deriveremo il nome dell'immagine dall'id della riga (contenuta in name) secondo il seguente formato:
fname = "img_{id:05d}.{ext:s}"
Dove id indica l'id della riga, mentre ext è l'estensione. Per ottenere l'estensione corretta, recuperiamo quella contenuta nell URL mediante una espressione regolare:
import re #libreria per le espressioni regolari
ext = re.search('[^.]+$',row['img_url']).group()
ext
Il nome del file di destinazione diventa dunque:
fname.format(id=int(row.name),ext=ext)
Il percorso completo del file di destinazione possiamo ottenerlo concatenando il path della cartella e il nome del file mediante la funzione join di os.dir:
from os.path import join
fullpath = join(dest_dir,fname.format(id=int(row.name),ext=ext))
fullpath
Possiamo dunque scaricare il file mediante urllib come segue:
from urllib.request import urlretrieve as retrieve
retrieve(row['img_url'], fullpath)
Proviamo a caricare l'immagine con PIL per controllare che sia stata correttamente salvata su disco:
from PIL import Image
Image.open(fullpath)
Scriviamo adesso una funzione per automatizzare il download delle immagini. Per poter tenere traccia delle immagini che salviamo sul disco, inseriremo un nuovo campo img_path al DataFrame.
def download_images(data, dest_dir, fname="img_{id:05d}.{ext:s}"):
data=data.copy() #preserva il dataframe originale
img_paths= []
data['img_path']=None #crea una nuova colonna
for i, row in data.iterrows():
ext = re.search('[^.]+$',row['img_url']).group()
fullpath = join(dest_dir,fname.format(id=int(row.name),ext=ext))
retrieve(row['img_url'], fullpath)
img_paths.append(fullpath)
data['img_path']=img_paths
return data
Utilizziamo la funzione per scaricare le immagini:
data2 = download_images(data, 'amazon_img')
Visualizziamo le prime righe del nuovo DataFrame:
data2.head()
Controlliamo il numero di immagini in amazon_img:
from glob import glob
len(glob('amazon_img/*'))
Possiamo comunque controllare la cartella che abbiamo creato e notare che conterrà tutte le immagini dei prodotti:
Abbiamo visto che la lista dei prodotti si estende su più pagine. Vediamo adesso come navigare tra le pagine per raccogliere le informazioni su tutti i prodotti. Andiamo in fondo alla lista e clicchiamo su uno dei pulsanti per aprire le pagine. Ci accorgiamo che il formato dei link delle pagine è il seguente:
https://www.amazon.it/s?k=occhiali+da+sole&page=2&crid=FY51CBDZSXMD&qid=1697798329&sprefix=%2Caps%2C106&ref=sr_pg_2
Sfrutteremo questa caratteristica per navigare tra le pagine.
Dato un numero di pagina, possiamo trovare il link relativo con il formato:
p=1
f"https://www.amazon.it/s?k=occhiali+da+sole&page={p:d}&crid=FY51CBDZSXMD&qid=1697798329&sprefix=%2Caps%2C106&ref=sr_pg_2"
Per rendere il codice indipendente rispetto alla pagina:
part1_url = "https://www.amazon.it/s?k=occhiali+da+sole&page="
part2_url = "&crid=FY51CBDZSXMD&qid=1697798329&sprefix=%2Caps%2C106&ref=sr_pg_2"
f"{part1_url}{p:d}{part2_url}"
Rivediamo lo script precedente per automatizzare la navigazione tra le pagine e lo scraping:
def navigate_and_scrape(base_url, records = None):
if records is None:
records = []
all_records = records
page = 1 #iniziamo dalla pagina 1
while(True):
url = f"{part1_url}{p:d}{part2_url}"
records = scrape_page(url)
all_records.extend(records)
if len(records)==0: #usciamo quando la pagina non contiene più record
break
page+=1
return all_records
Utilizziamo la funzione per effettuare lo scraping. Inviando tutte queste richieste in successione, Amazon bloccherà l'accesso mostrando l'errore: HTTP Error 503: Service Unavailable:
Modifichiamo lo script in modo da fare scraping solo delle prime 3 pagine:
def navigate_and_scrape(base_url, records = None):
if records is None:
records = []
all_records = records
page = 1 #iniziamo dalla pagina 1
page_end = 3 #fermiamoci alle pagina 3
while(page <= page_end):
url = f"{part1_url}{p:d}{part2_url}"
records = scrape_page(url)
all_records.extend(records)
if len(records)==0: #usciamo quando la pagina non contiene più record
break
page+=1
return all_records
Utilizziamo la funzione modificata per effettuare lo scraping:
records = navigate_and_scrape(page_url)
data = pd.DataFrame(records, columns=['manufacturer','model','img_url','price','review_count','rating'])
Visualizziamo alcune informazioni sul DataFrame:
print(data.info())
data.head()
Vediamo adesso di analizzare in breve i dati ottenuti per capire qualcosa di più sui prodotti venduti dallo store. Iniziamo con delle semplici statistiche sulle variabili quantitative, che possono essere ottenute mediante il metodo describe dei DataFrame:
data.describe()
Vediamo adesso quanti prodotti sono contenuti per ciascuna marca:
from matplotlib import pyplot as plt
plt.figure(figsize=(8,8))
data.groupby('manufacturer')['manufacturer'].count().plot.pie(rotatelabels=True)
plt.show()
Notiamo che le marche che contengono più oggetti sono Polaroid, Vans e Hawkers. Calcoliamo e visualizziamo adesso il prezzo medio per marca. Questa volta però utilizziamo un grafico a barre:
plt.figure(figsize=(12,6))
data.groupby('manufacturer')['price'].mean().plot.bar()
plt.grid()
plt.show()
Gli oggetti più costosi in media sono gli occhiali "Police" e "Tommy Hilfiger". Cerchiamo di capire adesso quali sono gli occhiali più popolari. Inizieremo visualizzando il numero di review presenti in ogni categoria:
plt.figure(figsize=(12,6))
data.groupby('manufacturer')['review_count'].sum().plot.bar()
plt.grid()
plt.show()
Risposta 1
Pare che gli oggetti più recensiti siano gli occhiali "SUNGAIT" e "Hawkers". Vediamo adesso qual è il rating medio degli oggetti in ogni categoria:
plt.figure(figsize=(12,6))
data.groupby('manufacturer')['rating'].mean().plot.bar()
plt.grid()
plt.show()
Gli oggetti che hanno i rating più bassi sono quelli appartenenti alla marca "ZENOTTIC" (non considerando i "Police" visto che non hanno recensioni).Proviamo a vedere perché. Analizziamo solo quella categoria, mostrando il rating medio per marca:
plt.figure(figsize=(12,6))
data[data['manufacturer']=='ZENOTTIC'].groupby('manufacturer')['rating'].mean().plot.bar()
plt.grid()
plt.show()
Domanda 2 Stando a quanto mostrato nell'ultimo grafico, che tipo di correlazione c'è tra il rating e il numero di recensioni?
Risposta 2
Esercizio 2
Esistono ulteriori attributi relativi agli oggetti presi in considerazione come ad esempio l'etichetta "Prime" e anche i tempi di spedizione. Provare ad estrarre anche quelle informazioni e rieffettuare l'analisi statistica considerando anche i nuovi attributi.
Esercizio 3
Si scelga un altro sito a piacere e si ripeta su di esso una analisi simile a quella vista in laboratorio.